#!/usr/bin/env python
#
# core.py - library module for pyinsim
#
# Copyright 2008-2012 Alex McBride <xandermcbride@gmail.com>
#
# This software may be used and distributed according to the terms of the
# GNU Lesser General Public License version 3 or any later version.
#

# Dependencies.
from asyncore import dispatcher, dispatcher_with_send, socket_map, poll, close_all as asyncore_close_all
from socket import AF_INET, SOCK_STREAM, SOCK_DGRAM, TCP_NODELAY, IPPROTO_TCP
from traceback import print_exc
from threading import Thread
from time import time


# Libraries.
from pyinsim.insim import *


__all__ = ('Timer', 'EVT_CLOSE', 'EVT_CONNECT', 'EVT_ERROR', 'EVT_OUTGAUGE', 'EVT_OUTSIM', 'EVT_PACKET', 'InSimClient',
           'InSimError', 'OutSimClient', 'PYINSIM_VERSION', '_TcpBuffer', '_TcpSocket', '_UdpSocket', 'check_version',
           'insim_init', 'outgauge_init', 'outsim_init', 'relay_init', 'main_loop', 'is_running', 'close_all', 'timer',)


# Constants.
PYINSIM_VERSION = '3.0.0'
_TCP_BUFFER_SIZE = 2048
_UDP_BUFFER_SIZE = 256
_OUTGAUGE_SIZE = (92, 96)
_OUTSIM_SIZE = (64, 68)


# pyinsim events.
EVT_CONNECT = 256
EVT_CLOSE = 257
EVT_ERROR = 258
EVT_PACKET = 259
EVT_OUTSIM = 260
EVT_OUTGAUGE = 261


# Packet lookup.
_PACKET_MAP = {ISP_ISI: IS_ISI, ISP_VER: IS_VER, ISP_TINY: IS_TINY, ISP_SMALL: IS_SMALL, ISP_STA: IS_STA,
               ISP_SCH: IS_SCH, ISP_SFP: IS_SFP, ISP_SCC: IS_SCC, ISP_CPP: IS_CPP, ISP_ISM: IS_ISM, ISP_MSO: IS_MSO,
               ISP_III: IS_III, ISP_MST: IS_MST, ISP_MTC: IS_MTC, ISP_MOD: IS_MOD, ISP_VTN: IS_VTN, ISP_RST: IS_RST,
               ISP_NCN: IS_NCN, ISP_CNL: IS_CNL, ISP_CPR: IS_CPR, ISP_NPL: IS_NPL, ISP_PLP: IS_PLP, ISP_PLL: IS_PLL,
               ISP_LAP: IS_LAP, ISP_PIT: IS_PIT, ISP_PSF: IS_PSF, ISP_PLA: IS_PLA, ISP_CCH: IS_CCH, ISP_PEN: IS_PEN,
               ISP_TOC: IS_TOC, ISP_FLG: IS_FLG, ISP_PFL: IS_PFL, ISP_FIN: IS_FIN, ISP_RES: IS_RES, ISP_REO: IS_REO,
               ISP_NLP: IS_NLP, ISP_MCI: IS_MCI, ISP_MSX: IS_MSX, ISP_MSL: IS_MSL, ISP_CRS: IS_CRS, ISP_BFN: IS_BFN,
               ISP_AXI: IS_AXI, ISP_AXO: IS_AXO, ISP_BTN: IS_BTN, ISP_BTC: IS_BTC, ISP_BTT: IS_BTT, ISP_RIP: IS_RIP,
               ISP_SSH: IS_SSH, ISP_CON: IS_CON, ISP_OBH: IS_OBH, ISP_HLV: IS_HLV, ISP_PLC: IS_PLC, ISP_AXM: IS_AXM,
               ISP_ACR: IS_ACR, IRP_ARQ: IR_ARQ, IRP_ARP: IR_ARP, IRP_HLR: IR_HLR, IRP_HOS: IR_HOS, IRP_SEL: IR_SEL,
               IRP_ERR: IR_ERR, EVT_OUTSIM: OutSimPack, EVT_OUTGAUGE: OutGaugePack, ISP_SPX: IS_SPX}


# Functions.
def _build_packet(data, packet_type):
    """Build a packet from the packet data."""
    cls = _PACKET_MAP.get(packet_type)
    if cls:
        packet = cls()
        packet.unpack(data)
        return packet
    return None

        
def check_version(version, or_better=True):
    """Determine that the correct version of pyinsim is installed.

    Args:
        version   - the version to check for
        or_better - set false to check for the exact version

    Returns:
        True if the correct version of pyinsim is installed.

    """
    if or_better and version >= PYINSIM_VERSION:
        return True
    return version == PYINSIM_VERSION
        
        
def insim_init(host='127.0.0.1', port=29999, ReqI=1, UDPPort=0, Flags=0, Prefix='!', Interval=0, Admin='', IName='pyinsim'):
    """Initialise an InSim connection with LFS.

    Args:
        host     - the host to connect to
        port     - the port to connect to the host through
        ReqI     - set true for LFS to respond with a IS_VER packet on connecting
        UDPPort  - set UDP port LFS will use for IS_NLP or IS_MCI updates
        Flags    - various InSim init flags
        Prefix   - hidden host command prefix
        Interval - milliseconds between IS_NLP and IS_MCI updates
        Admin    - LFS game admin password
        IName    - short name for your program

    Returns:
        instance of an initialized InSimClient object

    """
    conn = InSimClient()
    conn.connect(host, port, UDPPort)
    conn.send(IS_ISI(ReqI=ReqI, UDPPort=UDPPort, Flags=Flags, Prefix=Prefix, Interval=Interval, Admin=Admin, IName=IName))
    return conn


def relay_init(host='isrelay.lfs.net', port=47474, ReqI=0, HName='', Admin='', Spec=''):
    """Initialise a InSim Relay connection and (optionally) select a Relay host.

    Args:
        host  - the InSim Relay host
        port  - the InSim Relay port
        ReqI  - if non-zero and selecting host, LFS will reply with a IS_VER packet
        HName - the host to select (with or without colours)
        Admin - the host admin password
        Spec  - the host spectator password

    Returns:
        instance of an initialized InSimClient object

    """
    conn = InSimClient()
    conn.connect(host, port)
    if HName:
        conn.send(IR_SEL(ReqI=ReqI, HName=HName, Admin=Admin, Spec=Spec))
    return conn
    
    
def outsim_init(host='127.0.0.1', port=30000, callback=None, timeout=30.0):
    """Initialise a OutSim connection.

    Args:
        host - the host to listen for packets from
        port - the port to listen on
        callback - a function to call when a OutSim packet is recieved
        timeout - the seconds to wait between packets before timing out

    Returns:
        instance of an initialized OutSimClient object

    """
    conn = OutSimClient(timeout)
    conn.connect(host, port)
    if callback:
        conn.bind_event(EVT_OUTSIM, callback)
    return conn
    
    
def outgauge_init(host='127.0.0.1', port=30000, callback=None, timeout=30.0):
    """Initialise a OutGauge connection.

    Args:
        host - the host to listen for packets from
        port - the port to listen on
        callback - a function to call when a OutGauge packet is recieved
        timeout - the seconds to wait between packets before timing out

    Returns:
        instance of an initialized OutSimClient object

    """
    conn = OutSimClient(timeout)
    conn.connect(host, port)
    if callback:
        conn.bind_event(EVT_OUTGAUGE, callback)
    return conn


class _TimerManager:
    """Class to manage non-threaded timers."""
    def __init__(self):
        """Create a new TimerManager object."""
        self.timers = []
        
    def add_timer(self, timer):
        """Add a timer to the timers list."""
        if timer not in self.timers:
            self.timers.append(timer)
            self.force_select_return()
    
    def remove_timer(self, timer):
        """Remove a timer from the list."""
        if timer in self.timers:
            self.timers.remove(timer)
            self.force_select_return()
    
    def force_select_return(self):
        """Hack to make select return."""
        # HACK: In order to update the select timeout we first need to get the
        # select call to return, so we ping LFS to get it to send us some data.
        for s in socket_map.values():
            # Make sure we're connected and don't ping on UDP.
            if s.connected and isinstance(s, _TcpSocket):
                s.send(IS_TINY(ReqI=1, SubT=TINY_PING).pack())
                break    
    
    def execute_timers(self):
        """Check for elapsed timers and execute their callbacks."""
        time_now = time()
        for timer in list(self.timers):
            if timer.has_elapsed(time_now):
                timer.execute_callback()
                if timer.repeat:
                    timer.reset(time_now)
                else:
                    self.remove_timer(timer)
                    
    def get_timeout(self):
        """Get the number of seconds until the next timer is due to elapse."""
        time_now = time()
        timeout = None
        for timer in self.timers:
            interval = timer.get_interval(time_now)
            if interval > 0:
                if timeout is None or interval < timeout:
                    timeout = interval
        return timeout        


# Global timer manager.
_timer_mgr = _TimerManager()


class Timer:
    """Class to represent a non-threaded timer. These timers are executed on the
    main pyinsim program thread."""
    def __init__(self, interval, callback, repeat=False, *args, **kwargs):
        """Create a new timer object.

        Args:
            interval - seconds to wait before elapsing
            callback - function to call once elapsed
            repeat   - whether the timer will run only once or repeat until stopped
            args     - args to pass to the callback function when called
            kwargs   - kwargs to pass to the callback function when called

        """
        self.interval = interval
        self.callback = callback
        self.repeat = repeat
        self.args = args
        self.kwargs = kwargs
        self.due_time = None

    def start(self):
        """Start the timer."""
        self.reset()
        _timer_mgr.add_timer(self)

    def stop(self):
        """Stop the timer."""
        _timer_mgr.remove_timer(self)
        
    def reset(self, time_now=None):
        """Reset how long until the timer is due to elapse."""
        # TODO: combine this with start?
        if not time_now:
            time_now = time()
        self.due_time = time_now + self.interval
    
    def has_elapsed(self, time_now):
        """Determine if the timer has elapsed."""
        return time_now >= self.due_time
    
    def get_interval(self, time_now):
        """Determine the number of seconds until the timer is due to elapse."""
        return self.due_time - time_now

    def execute_callback(self):
        """Execute the timer callback function."""
        self.callback(*self.args, **self.kwargs)


def timer(interval, callback, repeat=False, *args, **kwargs):
    """Create and start a new non-threaded timer.

    Args:
        interval - the time until the timer is due to elapse
        callback - the function to call once the timer elapses
        repeat   - whether to execute the timer once or keep repeating

    """
    t = Timer(interval, callback, repeat, *args, **kwargs)
    t.start()
    return t


def _asyncore_loop():
    """Replacement for asyncore.loop() function to add timer support."""
    s_map = socket_map
    poll_fun = poll

    while s_map:
        _timer_mgr.execute_timers()
    
        timeout = _timer_mgr.get_timeout()
              
        poll_fun(timeout, s_map)


def main_loop(background=False):
    """Start the pyinsim packet receive loop. pyinsim will stay in this loop until all connections are closed.

    Args:
        background - set True for the loop to be run on a background thread.

    """
    if background:
        Thread(target=_asyncore_loop).start()
    else:
        _asyncore_loop()
        
        
def is_running():
    """Determine if the pyinsim event loop is running."""
    for s in socket_map.values():
        if s.connected:
            return True
    return False
    
    
def close_all():
    """Close all current connections and end the event loop."""
    asyncore_close_all()


# Classes.
class InSimError(Exception):
    """Class to represent an InSim error."""
    pass
            
               
class _TcpBuffer:
    """Class to handle TCP receive buffer."""
    def __init__(self):
        """Init new TcpBuffer class."""
        self.buffer = b''
    
    def has_packets(self):
        return self.buffer and len(self.buffer) >= self.buffer[0]
    
    def get_packets(self):
        while self.has_packets():
            size = self.buffer[0]
            if size % 4:
                raise InSimError('invalid packet size')
            data = self.buffer[:size]
            self.buffer = self.buffer[size:]
            yield data    
    
    def append(self, data):
        """Append data to the buffer."""
        self.buffer += data             
            
             
class _TcpSocket(dispatcher_with_send):
    """class to handle a TCP socket with LFS."""
    def __init__(self, dispatch_to):
        """Init new TcpSocket object."""
        dispatcher_with_send.__init__(self)
        self.create_socket(AF_INET, SOCK_STREAM)
        self.socket.setsockopt(IPPROTO_TCP, TCP_NODELAY, 1)
        self.dispatch_to = dispatch_to
        self.recv_buffer = _TcpBuffer()
  
    def send(self, data):
        """Send buffer data to LFS."""
        self.out_buffer += data
        if self.connected:
            self.initiate_send()
    
    def handle_connect(self):
        """Handle socket connect event."""
        self.dispatch_to.handle_connect(self)
        
    def handle_close(self):
        """Handle socket close event."""
        self.close()
        self.dispatch_to.handle_close(self)
    
    def handle_read(self):
        """Handle socket read event."""
        data = self.recv(_TCP_BUFFER_SIZE)
        if data:
            self.recv_buffer.append(data)
            for data in self.recv_buffer.get_packets():
                self.dispatch_to.handle_read(self, data)
    
    def handle_error(self):
        """Handle socket error event."""
        self.close()
        self.dispatch_to.handle_error(self)
    
    
class _UdpSocket(dispatcher):
    """Class to handle a UDP socket with LFS."""
    def __init__(self, dispatch_to):
        """Init a new UdpSocket object."""
        dispatcher.__init__(self)
        self.create_socket(AF_INET, SOCK_DGRAM)
        self.dispatch_to = dispatch_to
        
    def writable(self):
        """Get if the socket is writable."""
        return False
        
    def handle_connect(self):
        """Handle socket connect event."""
        self.dispatch_to.handle_connect(self)
        
    def handle_close(self):
        """Handle socket close event."""
        self.close()
        self.dispatch_to.handle_close(self)
        
    def handle_read(self):
        """Handle socket read event."""
        data = self.recv(_UDP_BUFFER_SIZE)
        if data:
            if len(data) % 4:
                raise InSimError('invalid packet size')        
            self.dispatch_to.handle_read(self, data)
            
    def handle_error(self):
        """Handle socket error event."""
        self.close()
        self.dispatch_to.handle_error(self)
           
            
class _EventMixin:
    """Mixin for clients to handle pyinsim events."""
    def __init__(self):
        """Create a new EventBinding object."""
        self._event_bindings = {}
        
    def bind_event(self, event_type, event_callback):
        """Bind a function to be called when an event occurs.

        Args:
            event_type     - the type of event to bind
            event_callback - the function to call when the event occurs

        """
        callbacks = self._event_bindings.get(event_type)
        if callbacks:
            callbacks.append(event_callback)
        else:
            self._event_bindings[event_type] = [event_callback]
 
    def unbind_event(self, event_type, event_callback):
        """Unbind an event function.

        Args:
            event_type     - the type of event to unbind
            event_callback - the function that was originally bound

        """
        callbacks = self._event_bindings.get(event_type)
        if callbacks:
            callbacks.remove(event_callback)
            if not callbacks:
                del self._event_bindings[event_type]
                
    def is_event_bound(self, event_type, event_callback):
        """Determine if an event function is bound.

        Args:
            event_type     - the type of event to check
            event_callback - the function to check for

        """
        callbacks = self._event_bindings.get(event_type)
        if callbacks:
            return event_callback in callbacks
        return False
 
    def raise_event(self, event_type, *args):
        """Raise an event.

        Args:
            event_type - the type of event to raise an event for
            args       - the arguments to pass to the event function

        """
        callbacks = self._event_bindings.get(event_type)
        if callbacks:
            [c(self, *args) for c in callbacks]
            
    def _get_bindings(self, event_type):
        """Get the functions bound to an event."""
        return self._event_bindings.get(event_type, [])
        
    def _raise_packet_event(self, packet_type, data):
        """Build a packet and raise an event for it."""
        callbacks = self._get_bindings(packet_type) + self._get_bindings(EVT_PACKET)
        if callbacks:          
            # Build packet here so as not to build more packets than necessary.
            packet = _build_packet(data, packet_type)
            if not packet:
                raise InSimError("missing packet '%d'" % packet_type)
            if callbacks:
                [c(self, packet) for c in callbacks]
        
        
class InSimClient(_EventMixin):
    """Class to represent a InSim connection with LFS."""
    def __init__(self, name=None):
        """Create a new InSim object.

        Args:
            name - an optional name for the connection

        """
        _EventMixin.__init__(self)
        self._tcp_socket = _TcpSocket(self)
        self._udp_socket = None
        self.name = name
               
    def connected(self):
        """Determines if LFS is connected."""
        return self._tcp_socket.connected
        
    def connect(self, host, port, udpport=0):
        """Connect to LFS.

        Args:
            host    - the host to connect to
            port    - the port to connect through
            udpport - an optional UDP port to open

        """
        self._tcp_socket.connect((host, port))
        if udpport:
            self._udp_socket = _UdpSocket(self)
            self._udp_socket.bind((host, udpport))
            
    def close(self):
        """Close the connection with LFS."""
        self._tcp_socket.close()
        if self._udp_socket:
            self._udp_socket.close()

    def send(self, packet):
        """Send a packet to LFS.

        Args:
            packet - the packet to send

        """
        self._tcp_socket.send(packet.pack())
        
    def send_packets(self, packets):
        """Send a sequence of packets to LFS.

        Args:
            packets - an iterable of packets to send

        """
        data = b''.join([p.pack() for p in packets])
        self._tcp_socket.send(data)
        
    def send_messages(self, messages, ucid=0, plid=0):
        """Send a sequence of message to LFS, or to a specific connection or 
        player. The correct message packet will be sent according to the 
        message parameters.

        Args:
            messages - the iterable of messages to send
            ucid    - the optional UCID to send the message to
            plid    - the optional PLID to send the message to

        """
        data = b''.join([self._get_send_message(m, ucid, plid) for m in messages])
        self._tcp_socket.send(data)
        
    def send_message(self, message, ucid=0, plid=0):
        """Send a message to LFS. The correct message packet will be sent 
        according to the message parameters.

        Args:
            message - the message to send
            ucid    - the optional UCID to send the message to
            plid    - the optional PLID to send the message to

        """
        self._tcp_socket.send(self._get_send_message(message, ucid, plid))
        
    def _get_send_message(self, message, ucid, plid):
        if ucid or plid:
            return IS_MTC(UCID=ucid, PLID=plid, Text=message).pack()
        elif len(message) < 64:
            return IS_MST(Msg=message).pack()
        elif len(message) < 92:
            return IS_MSX(Msg=message).pack()
        return IS_MSX(Msg=message[:92]).pack()
        
    def handle_connect(self, sock):
        """Handle connect event."""
        # Only raise connect for TCP.
        if sock == self._tcp_socket:
            self.raise_event(EVT_CONNECT)
    
    def handle_close(self, _):
        """Handle close event."""
        self.close()
        self.raise_event(EVT_CLOSE)
    
    def handle_error(self, _):
        """Handle error event."""
        self.close()
        self.raise_event(EVT_ERROR)
        print_exc() # Print stacktrace.
       
    def handle_read(self, _, data):
        """Handle read event."""
        packet_type = data[1]
        self.handle_internal(packet_type, data)
        self._raise_packet_event(packet_type, data)
        
    def handle_internal(self, packet_type, data):
        """Handle internal packet events."""
        if packet_type == ISP_TINY:
            tiny = IS_TINY().unpack(data)
            if tiny.SubT == TINY_NONE:
                self._tcp_socket.send(data) # Keep alive.
        elif packet_type == ISP_VER:
            ver = _build_packet(data, packet_type)
            if ver.InSimVer != INSIM_VERSION:
                raise InSimError('invalid insim version')
             
                                    
class OutSimClient(_EventMixin):
    """Class to represent a OutSim or OutGauge connection with LFS."""
    def __init__(self, timeout=None, name=None):
        """Create a new OutSim object.

        Args:
            timeout - the seconds to wait for a packet before timing out
            name    - an optional name for the connection

        """
        _EventMixin.__init__(self)
        self._udp_socket = _UdpSocket(self)
        # Set timer to handle UDP timeout.
        self._ttimer = None
        if timeout:
            self._ttimer = Timer(timeout, self.handle_timeout)
        self.name = name

    def connect(self, host, port):
        """Connect to LFS.

        Args:
            host - the host to connect to
            port - the port to connect through

        """
        self._udp_socket.bind((host, port))
        if self._ttimer:
            self._ttimer.start()
        
    def close(self):
        """Close the connection to LFS."""
        self._udp_socket.close()
        if self._ttimer:
            self._ttimer.stop()
        
    def handle_connect(self, _):
        """Handle connect event."""
        self.raise_event(EVT_CONNECT)
    
    def handle_close(self, _):
        """Handle close event."""
        self.close()
        self.raise_event(EVT_CLOSE)
    
    def handle_error(self, _):
        """Handle error event."""
        self.close()
        self.raise_event(EVT_ERROR)
        print_exc()
    
    def _get_packet_type(self, data):
        """Get the type of the UDP packet."""
        size = len(data)
        if size in _OUTGAUGE_SIZE:
            return EVT_OUTGAUGE
        elif size in _OUTSIM_SIZE:
            return EVT_OUTSIM
        return None
    
    def handle_read(self, _, data):
        """Handle read event."""
        packet_type = self._get_packet_type(data)
        if packet_type and packet_type in (EVT_OUTSIM, EVT_OUTGAUGE):
            # Update timeout due time to account for data received.
            if self._ttimer:
                self._ttimer.reset()
            self._raise_packet_event(packet_type, data)
            
    def handle_timeout(self):
        """Handle timeout event."""
        self.close()
        self.raise_event(EVT_CLOSE)


if __name__ == '__main__':
    pass

